Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit b46d018b265c25ca8701af59287806891a9bd502


Parents : 44608ff
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-05T20:22:16-06:00

feat(ui): update user interface with improved icon handling, audio permission checks, and dynamic message icon size adjustments

Changes
Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 76172597..fb3538c9 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -85,7 +85,7 @@
<MaterialDesignIcon
icon-name="refresh"
class="size-6"
- :class="{ 'animate-spin-reverse': isSyncingPropagationNode }"
+ :class="{ 'animate-spin': isSyncingPropagationNode }"
/>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
isSyncingPropagationNode

diff --git a/meshchatx/src/frontend/components/LxmfUserIcon.vue b/meshchatx/src/frontend/components/LxmfUserIcon.vue
index d054c887..c174dd83 100644
--- a/meshchatx/src/frontend/components/LxmfUserIcon.vue
+++ b/meshchatx/src/frontend/components/LxmfUserIcon.vue
@@ -3,13 +3,14 @@
v-if="customImage"
class="rounded-full overflow-hidden shrink-0 flex items-center justify-center"
:class="iconClass || 'size-6'"
+ :style="iconStyle"
>
<img :src="customImage" class="w-full h-full object-cover" />
</div>
<div
v-else-if="iconName"
class="p-[10%] rounded-full shrink-0 flex items-center justify-center"
- :style="{ 'background-color': finalBackgroundColor }"
+ :style="[iconStyle, { 'background-color': finalBackgroundColor }]"
:class="iconClass || 'size-6'"
>
<MaterialDesignIcon :icon-name="iconName" class="size-full" :style="{ color: finalForegroundColor }" />
@@ -18,6 +19,7 @@
v-else
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
:class="iconClass || 'size-6'"
+ :style="iconStyle"
>
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
</div>
@@ -51,6 +53,10 @@ export default {
type: String,
default: "",
},
+ iconStyle: {
+ type: Object,
+ default: () => ({}),
+ },
},
computed: {
finalForegroundColor() {

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index fc82b149..bd5f3ca7 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2411,18 +2411,26 @@ export default {
},
async onToggleWebAudio(newVal) {
if (!this.config) return;
+ const previousValue = this.config.telephone_web_audio_enabled;
this.config.telephone_web_audio_enabled = newVal;
try {
+ if (newVal) {
+ const permitted = await this.requestAudioPermission();
+ if (!permitted) {
+ this.config.telephone_web_audio_enabled = false;
+ await this.updateConfig({ telephone_web_audio_enabled: false });
+ return;
+ }
+ }
await this.updateConfig({ telephone_web_audio_enabled: newVal });
if (newVal) {
- await this.requestAudioPermission();
await this.startWebAudio();
} else {
this.stopWebAudio();
}
} catch {
// revert on failure
- this.config.telephone_web_audio_enabled = !newVal;
+ this.config.telephone_web_audio_enabled = previousValue;
}
},
async startWebAudio() {
@@ -2430,7 +2438,18 @@ export default {
return;
}
try {
- const constraints = this.selectedAudioInputId
+ await this.refreshAudioDevices();
+ const hasInputDevices = (this.audioInputDevices || []).length > 0;
+ if (!hasInputDevices) {
+ ToastUtils.error(this.$t("call.no_audio_input_found"));
+ this.config.telephone_web_audio_enabled = false;
+ await this.updateConfig({ telephone_web_audio_enabled: false });
+ return;
+ }
+
+ const validDeviceIds = new Set((this.audioInputDevices || []).map((d) => d.deviceId));
+ const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
+ const constraints = hasSelectedDevice
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
: { audio: true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
@@ -2481,17 +2500,44 @@ export default {
this.refreshAudioDevices();
} catch (err) {
console.error("Web audio failed", err);
- ToastUtils.error(this.$t("call.web_audio_not_available"));
+ const errorKey =
+ err?.name === "NotFoundError" || err?.name === "OverconstrainedError"
+ ? "call.no_audio_input_found"
+ : err?.name === "NotAllowedError"
+ ? "call.microphone_permission_denied"
+ : "call.web_audio_not_available";
+ ToastUtils.error(this.$t(errorKey));
+ this.config.telephone_web_audio_enabled = false;
+ await this.updateConfig({ telephone_web_audio_enabled: false });
this.stopWebAudio();
}
},
async requestAudioPermission() {
try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const hasAudioInput = devices.some((d) => d.kind === "audioinput");
+ if (devices.length > 0 && !hasAudioInput) {
+ ToastUtils.error(this.$t("call.no_audio_input_found"));
+ return false;
+ }
+
+ const constraints = this.selectedAudioInputId
+ ? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
+ : { audio: true };
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
stream.getTracks().forEach((t) => t.stop());
await this.refreshAudioDevices();
+ return true;
} catch (e) {
console.error("Permission or device request failed", e);
+ const errorKey =
+ e?.name === "NotFoundError" || e?.name === "OverconstrainedError"
+ ? "call.no_audio_input_found"
+ : e?.name === "NotAllowedError"
+ ? "call.microphone_permission_denied"
+ : "call.web_audio_not_available";
+ ToastUtils.error(this.$t(errorKey));
+ return false;
}
},
async refreshAudioDevices() {

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 1ea0145e..51d2ec22 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -164,8 +164,8 @@
Recently Heard Announces
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
- Cards appear/disappear as announces are heard. Connected entries show a green
- pill; disconnected entries are dimmed with a red label.
+ Discovery runs continually; heard announces stay listed. Connected entries show
+ a green pill; disconnected entries are dimmed with a red label.
</div>
</div>
<div class="flex gap-2">
@@ -193,7 +193,7 @@
v-if="sortedDiscoveredInterfaces.length === 0"
class="text-sm text-gray-500 dark:text-gray-300"
>
- No discovered interfaces yet.
+ {{ discoveredEmptyMessage }}
</div>
<div
@@ -627,6 +627,15 @@ export default {
});
return set;
},
+ discoveredEmptyMessage() {
+ if (!this.isReticulumRunning) {
+ return "LXMF/Reticulum is not running; discovery cannot listen for announces.";
+ }
+ if (!this.discoveryConfig.discover_interfaces) {
+ return "Discovery is disabled. Enable it to start listening for announces.";
+ }
+ return "Discovery is working, be patient while it waits for announces.";
+ },
},
beforeUnmount() {
clearInterval(this.reloadInterval);
@@ -783,12 +792,40 @@ export default {
async loadDiscoveredInterfaces() {
try {
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
- this.discoveredInterfaces = response.data?.interfaces ?? [];
- this.discoveredActive = response.data?.active ?? [];
+ const incoming = response.data?.interfaces ?? [];
+ const active = response.data?.active ?? [];
+
+ const merged = new Map();
+ const addOrUpdate = (iface, isNew = false) => {
+ const key = this.discoveryKey(iface);
+ const existing =
+ merged.get(key) || this.discoveredInterfaces.find((i) => this.discoveryKey(i) === key);
+ const lastHeard = iface.last_heard ?? existing?.last_heard ?? Math.floor(Date.now() / 1000);
+ merged.set(key, {
+ ...existing,
+ ...iface,
+ last_heard: lastHeard,
+ __isNew: isNew || existing?.__isNew,
+ });
+ };
+
+ this.discoveredInterfaces.forEach((iface) => addOrUpdate(iface, false));
+ incoming.forEach((iface) => addOrUpdate(iface, true));
+
+ this.discoveredInterfaces = Array.from(merged.values());
+ this.discoveredActive = active;
} catch (e) {
console.log(e);
}
},
+ discoveryKey(iface) {
+ return (
+ iface.discovery_hash ||
+ `${iface.reachable_on || iface.target_host || iface.remote || iface.listen_ip || iface.name || "unknown"}:${
+ iface.port || iface.target_port || iface.listen_port || ""
+ }`
+ );
+ },
formatLastHeard(ts) {
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (seconds < 60) return `${seconds}s ago`;

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index f2bb9471..993c636a 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -35,7 +35,8 @@
:icon-background-colour="
selectedPeer.lxmf_user_icon ? selectedPeer.lxmf_user_icon.background_colour : ''
"
- icon-class="size-11"
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
/>
</div>
@@ -956,7 +957,7 @@
<MaterialDesignIcon
icon-name="sync"
class="size-6"
- :class="{ 'animate-spin-reverse': isSyncingPropagationNode }"
+ :class="{ 'animate-spin': isSyncingPropagationNode }"
/>
</div>
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">{{
@@ -1482,6 +1483,15 @@ export default {
};
},
computed: {
+ messageIconStyle() {
+ const size = Number(this.config?.message_icon_size) || 28;
+ return {
+ width: `${size}px`,
+ height: `${size}px`,
+ minWidth: `${size}px`,
+ minHeight: `${size}px`,
+ };
+ },
isSyncingPropagationNode() {
return [
"path_requested",

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 7f12ac1d..96f26de9 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -290,7 +290,7 @@
<div v-if="isLoading" class="w-full divide-y divide-gray-100 dark:divide-zinc-800">
<div v-for="i in 6" :key="i" class="p-3 animate-pulse">
<div class="flex gap-3">
- <div class="size-10 rounded bg-gray-200 dark:bg-zinc-800"></div>
+ <div class="rounded bg-gray-200 dark:bg-zinc-800" :style="messageIconStyle"></div>
<div class="flex-1 space-y-2 py-1">
<div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-3/4"></div>
<div class="h-2 bg-gray-200 dark:bg-zinc-800 rounded w-1/2"></div>
@@ -366,7 +366,8 @@
:icon-background-colour="
conversation.lxmf_user_icon ? conversation.lxmf_user_icon.background_colour : ''
"
- icon-class="size-7"
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
/>
</div>
<div class="mr-auto w-full pr-2 min-w-0">
@@ -559,7 +560,8 @@
:icon-name="peer.lxmf_user_icon?.icon_name"
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
- icon-class="size-7"
+ icon-class="shrink-0"
+ :icon-style="messageIconStyle"
/>
</div>
<div class="min-w-0 flex-1">
@@ -795,6 +797,10 @@ export default {
allSelected() {
return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
},
+ messageIconStyle() {
+ const size = GlobalState.config?.message_icon_size || 28;
+ return { width: `${size}px`, height: `${size}px` };
+ },
},
watch: {
foldersExpanded(newVal) {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index a475e210..e3a09204 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -14,28 +14,20 @@
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("app.profile") }}
</div>
- <div class="text-2xl font-semibold text-gray-900 dark:text-white">
- {{ config.display_name }}
+ <div class="flex flex-col sm:flex-row sm:items-center gap-2">
+ <div class="flex-1 min-w-0">
+ <input
+ v-model="config.display_name"
+ type="text"
+ :placeholder="$t('app.display_name_placeholder')"
+ class="w-full rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-base font-semibold text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500/60 focus:border-blue-500 outline-none transition"
+ @input="onDisplayNameChange"
+ />
+ </div>
+ <div class="text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
+ {{ $t("app.manage_identity") }}
+ </div>
</div>
- <div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("app.manage_identity") }}</div>
- </div>
- <div class="flex flex-col sm:flex-row gap-2">
- <button
- type="button"
- class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition"
- @click="copyValue(config.identity_hash, $t('app.identity_hash'))"
- >
- <MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
- {{ $t("app.identity_hash") }}
- </button>
- <button
- type="button"
- class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition"
- @click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
- >
- <MaterialDesignIcon icon-name="account-plus" class="w-4 h-4" />
- {{ $t("app.lxmf_address") }}
- </button>
</div>
</div>
<transition name="fade">
@@ -280,6 +272,22 @@
</div>
</button>
+ <button
+ type="button"
+ class="btn-maintenance border-emerald-200 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
+ @click="clearLxmfIcons"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="account-off" class="size-4" />
+ {{ $t("maintenance.clear_lxmf_icons") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_lxmf_icons_desc") }}
+ </div>
+ </div>
+ </button>
+
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@@ -621,15 +629,59 @@
</div>
</div>
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Size</div>
+ <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
+ {{ config.message_icon_size || 28 }}px
+ </div>
+ </div>
+ <div class="flex items-center gap-3">
+ <MaterialDesignIcon icon-name="account-outline" class="text-gray-400" />
+ <input
+ v-model.number="config.message_icon_size"
+ type="range"
+ min="16"
+ max="64"
+ step="1"
+ class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ @input="onMessageIconSizeChange"
+ />
+ <MaterialDesignIcon icon-name="account" class="text-gray-500 dark:text-gray-300" />
+ </div>
+ </div>
+
<div
- class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2"
+ class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2"
>
- <div>{{ $t("app.live_preview") }}</div>
+ <div
+ :style="messageIconPreviewStyle"
+ class="flex items-center justify-center shrink-0 rounded-full bg-gray-100 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700"
+ >
+ <LxmfUserIcon
+ :key="config.message_icon_size"
+ icon-name="account"
+ icon-class="w-full h-full"
+ icon-foreground-colour="#374151"
+ icon-background-colour="#e5e7eb"
+ />
+ </div>
+ <div class="flex-1 min-w-0 space-y-0.5">
+ <div
+ class="font-semibold text-gray-900 dark:text-gray-100"
+ :style="previewTextStyle"
+ >
+ Preview Name
+ </div>
+ <div class="text-gray-600 dark:text-gray-400 truncate" :style="previewTextStyle">
+ Hey there, this is how text and icons will look.
+ </div>
+ </div>
<span
class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase"
>
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
- {{ $t("app.realtime") }}
+ {{ $t("app.live_preview") }}
</span>
</div>
</div>
@@ -1168,6 +1220,7 @@ import Toggle from "../forms/Toggle.vue";
import ShortcutRecorder from "./ShortcutRecorder.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
+import LxmfUserIcon from "../LxmfUserIcon.vue";
export default {
name: "SettingsPage",
@@ -1175,6 +1228,7 @@ export default {
MaterialDesignIcon,
Toggle,
ShortcutRecorder,
+ LxmfUserIcon,
},
data() {
return {
@@ -1193,10 +1247,13 @@ export default {
lxmf_local_propagation_node_enabled: null,
lxmf_preferred_propagation_node_destination_hash: null,
archives_max_storage_gb: 1,
+ backup_max_count: 5,
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
blackhole_integration_enabled: true,
+ message_font_size: 14,
+ message_icon_size: 28,
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
gitea_base_url: "https://git.quad4.io",
@@ -1256,6 +1313,7 @@ export default {
"app.light_theme",
"app.dark_theme",
"Message Font Size",
+ "Icon Size",
"app.live_preview",
"app.realtime",
],
@@ -1337,6 +1395,20 @@ export default {
}
return this.config;
},
+ previewTextStyle() {
+ const size = this.config?.message_font_size || 14;
+ return { "font-size": `${size}px` };
+ },
+ messageIconPreviewStyle() {
+ const size = Number(this.config?.message_icon_size) || 28;
+ return {
+ width: `${size}px`,
+ height: `${size}px`,
+ minWidth: `${size}px`,
+ minHeight: `${size}px`,
+ transition: "width 120ms linear, height 120ms linear",
+ };
+ },
},
beforeUnmount() {
// stop listening for websocket messages
@@ -1452,6 +1524,28 @@ export default {
);
}, 1000);
},
+ async onDisplayNameChange() {
+ if (this.saveTimeouts.display_name) clearTimeout(this.saveTimeouts.display_name);
+ this.saveTimeouts.display_name = setTimeout(async () => {
+ await this.updateConfig(
+ {
+ display_name: this.config.display_name,
+ },
+ "display_name"
+ );
+ }, 600);
+ },
+ async onMessageIconSizeChange() {
+ if (this.saveTimeouts.message_icon_size) clearTimeout(this.saveTimeouts.message_icon_size);
+ this.saveTimeouts.message_icon_size = setTimeout(async () => {
+ await this.updateConfig(
+ {
+ message_icon_size: this.config.message_icon_size,
+ },
+ "message_icon_size"
+ );
+ }, 1000);
+ },
async onLanguageChange() {
await this.updateConfig(
{
@@ -1780,6 +1874,15 @@ export default {
ToastUtils.error(this.$t("common.error"));
}
},
+ async clearLxmfIcons() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.axios.delete("/api/v1/maintenance/lxmf-icons");
+ ToastUtils.success(this.$t("maintenance.lxmf_icons_cleared"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {

diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue
index c1c30b89..a8cb7593 100644
--- a/meshchatx/src/frontend/components/tools/BotsPage.vue
+++ b/meshchatx/src/frontend/components/tools/BotsPage.vue
@@ -83,7 +83,7 @@
<div>
<div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
<div class="text-xs font-mono text-gray-500">
- {{ runningMap[bot.id]?.address || "Not running" }}
+ {{ bot.address || runningMap[bot.id]?.address || "Not running" }}
</div>
<div class="text-[10px] text-gray-400">
{{ bot.template_id || bot.template }}
@@ -277,7 +277,7 @@ export default {
try {
await window.axios.post("/api/v1/bots/start", {
bot_id: bot.id,
- template_id: bot.template_id,
+ template_id: bot.template_id || bot.template,
name: bot.name,
});
ToastUtils.success(this.$t("bots.bot_started"));


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────